昨天那個不到十行的 usePrevious,最後停在一個沒有答案的地方:我們每天都說某個 Hook 很複雜,卻很少說得清楚,那個複雜度到底是從哪裡長出來的。
「複雜」這個詞的麻煩在於,它同時被拿來形容太多種感覺。程式碼很長是複雜,看不懂是複雜,看得懂但不敢動它也是複雜。三種感覺擠在同一個詞裡面,就沒辦法比較,也沒辦法追問下去。
今天想從三段我自己寫過的 Hook 出發,試著把這個詞拆成幾個可以分開談的概念。
如果使用 useEffect 抓取非同步資料,我們常會用客製化元件來封裝邏輯,為保持簡單,以下只用簡化過的寫法來呈現核心邏輯:
export function useUser(userId: string): User | null {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let ignore = false;
fetchUser(userId).then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
return user;
}
只要稍微熟悉 React,這段程式碼應該是淺顯易懂的:透過 useEffect 抓取用戶資料,然後把資料回傳出來。另外,也宣告了 ignore 變數,清理的時候把它設成 true,然後資料回來的時候用它來判斷要不要 setUser。
但老實說,第一次看到 ignore 的邏輯時令我蠻錯愕的,因為在學習 React 時,講師並沒有特別提及。我確實知道這段程式碼怎麼去處理這個變數,卻對它的具體用途所知甚少,進而疑惑:
那如果我把 ignore 相關的三行拿掉呢?
大部分時候,什麼事都不會發生。它通常只有在 userId 已經換掉、而上一個請求才姍姍來遲的時候才會出事 —— 畫面會停在錯的那個人身上。
這其實是一種競態條件 (race condition),比較晚回來的舊資料,把比較新的資料蓋掉了。而 ignore 主要就是來防止這種情況的。拿掉了,就少了一層防線。
這段程式碼裡面沒有任何一個字在講這件事。ignore 為什麼在這裡、拿掉會壞在哪,這些理由都不在這段程式碼裡面。它在寫下這段程式碼的那個人的腦子裡,而三個月後那個人也可能忘了。
💡 處理 API 請求,通常還需要管理 loading 與 error 狀態。另外也可以搭配 Web API 的 AbortController 直接取消請求,不過它取消的是請求本身,
ignore守的則是「要不要寫進狀態」,兩者處理的其實不是同一件事。
讓我們看看另外一個例子,體會一下另一種感覺。
這是一個提供多種管理頁面篩選狀態方法的 Hook:
type Filters = {
keyword: string;
tags: string[];
page: number;
};
export function useFilters() {
const [filters, setFilters] = useState<Filters>({
keyword: "",
tags: [],
page: 1,
});
const reset = useCallback(() => {
setFilters({ keyword: "", tags: [], page: 1 });
}, []);
const isEmpty =
filters.keyword === "" && filters.tags.length === 0 && filters.page === 1;
const toQueryString = useCallback(() => {
const params = new URLSearchParams();
if (filters.keyword !== "") params.set("keyword", filters.keyword);
if (filters.tags.length > 0) params.set("tags", filters.tags.join(","));
if (filters.page !== 1) params.set("page", String(filters.page));
return params.toString();
}, [filters]);
return { filters, setFilters, reset, isEmpty, toQueryString };
}
試著想像,如果現在產品說要多一個排序欄位,我們要如何改動這段程式碼呢?
我數了一下,得動五個地方:Filters 這個型別、useState 的初始值、reset 裡面那份預設值、isEmpty 的判斷式、toQueryString 的序列化。
麻煩的不是五這個數字,而是即使先更新了 Filters 型別,只要我在 isEmpty 的判斷中,或是 toQueryString 的邏輯裡忘了加進新的篩選條件,TypeScript 並不會提醒我,這些疏漏就有可能會悄悄成為日後 Bug 的來源。
這兩種感覺,John Ousterhout 在《A Philosophy of Software Design》裡各給了一個名字:晦澀 (obscurity) 以及 相依 (dependencies)。
在抓取資料範例裡的 ignore 是晦澀,重要的資訊沒有出現在明顯的地方;
篩選器的例子則是相依,一個改動牽連到多處程式碼,無法在一個地方完成。
在這本書裡,Ousterhout 把複雜度 (complexity) 定義為「任何與系統結構有關、使它難以理解與修改的東西」,而晦澀與相依,正是他點名的兩個成因。
不過,這本書談的是通用的軟體設計。而我們在這個系列只會專注於 Hook,因此定義上,可能會與書中有些微出入。
讓我們回頭看昨天的 usePrevious,它就像晦澀的一個小型例子:在 useEffect 裡的這段 ref.current = value,它默默決定了「上一次」指的是哪一次,但單看程式碼,卻很難意識得到。
防抖 (debounce) 是一個常見的效能優化邏輯,只要是頻繁觸發事件的操作,就可以考慮使用。也因為如此,它也是最常見的 React 客製化 Hook 之一,以下是其中一種寫法:
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
我們可以把這個 useDebouncedValue 應用在搜尋框上,來避免頻繁的 onChange 事件:
const SearchBox = () => {
const [keyword, setKeyword] = useState("");
const debounced = useDebouncedValue(keyword, 300);
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<Results keyword={debounced} />
</div>
);
};
不過目前的 useDebouncedValue 有一個問題:當需求變成「按 Enter 立刻搜尋」時,呼叫端無法要求等待中的 debounce 立即執行,因為 Hook 並沒有把任何控制能力暴露出來。
所以,另一種把計時器 id 回傳出去的版本就產生了:
export function useDebouncedValueWithTimer<T>(
value: T,
delay: number,
): { value: T; timerId: ReturnType<typeof setTimeout> | null } {
const [debounced, setDebounced] = useState(value);
const timerId = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
timerId.current = setTimeout(() => setDebounced(value), delay);
return () => {
if (timerId.current !== null) clearTimeout(timerId.current);
};
}, [value, delay]);
return { value: debounced, timerId: timerId.current };
}
💡 這兩版都是為了讓設計決定現形而寫的,並非可以直接上線的完整實作。其他可能要考慮的邏輯還包括處理卸載時的補送、
delay為 0 的情況,以及value是物件時的相等性比較等等。
兩版的功能是一樣的,不過第二版讓呼叫端可以透過拿到的 timerId,來決定清除計時器的時機。不過在享有這項優點的同時,呼叫端多了需要知道的事。
讓我們先看看這個 timerId 在每次渲染時,分別印出什麼:
timerId 序列:null → 52 → 55
數字本身沒有意義,換一台機器就會變。有意義的是它的身分。我們要拿這個 id 去清除計時器,得先知道三件事:
setTimeout
null
當然,最重要的還是第一項,因為呼叫端才知道要用對應的 clearTimeout 來清理。而這三件事,第一版的呼叫端一件都不用知道。
兩者差別不在功能,在於它們對呼叫端承諾了什麼,多給出來的東西也是一種額外的承諾。相對於第一版只承諾給出「一個延遲過的值」,第二版還承諾了,「你可以管理這個計時器」。
承諾一旦給出去,就不太收得回來了。哪天想把 setTimeout 換成別的排程方式,第一版可以直接換,但第二版沒辦法,因為已經有人拿著那個 id 了。
這件事叫做資訊隱藏 (information hiding):模組的作者要決定哪些實作細節要留在裡面、隱藏起來,哪些決策要變成對外承諾。
第二版並不是寫壞了。它在回答另一個問題:呼叫端有沒有可能比我更知道什麼時候該取消?如果答案是肯定的,那把計時器交出去就是合理的作法。當然,把 timerId 交出去只是比較極端的比方,如果 Hook 回傳的是把清理邏輯封裝的 cancel 函式,承諾的分量也會小一點。
我們也可以從這個角度去看上方 useFilters 的例子。當 filters 的結構暴露出去後,會有越來越多程式開始知道它長什麼樣子。
一旦某個結構被越來越多程式碼認得,不論是在 Hook 裡面還是外面,未來修改它時需要同步調整的地方就會增加。被藏在模組裡的實作細節通常不會有這個問題,因為外面根本不知道它存在。
如果現在有兩份 debounce 的實作擺在面前:
function useDebouncedValue<T>(value: T, delay: number): T;
它們的簽章一模一樣。呼叫端的程式碼一個字都不用改。差別是一份 20 行,另一份 400 行。
要選哪個?
這很看情況。有人說,程式碼是負債,因為隨著程式碼越多,維護成本及認知負擔也會上升,我也認同。
我想討論的是,函式的簽章一樣,代表呼叫端要知道的事情是一樣多的,就防抖而言,可以很輕量的就上述的程式碼範例來達成基本需求,但它同時也忽略掉一些邊緣案例,而這些案例,就可能是那多出的 380 行在處理的,比如說:
delay 在等待途中被改掉了,要不要重新計時這些情況在 20 行的版本裡不會消失。它們只是不在 Hook 裡面,當元件遇到狀況時,依然要回頭處理。
對呼叫端來說,要使用這兩種 Hook 的實作所必須知道的事情是一樣多的,都是傳入兩個參數,一個是要延遲的值,另一個則是代表毫秒的數字;也就是說,呼叫端接觸到的介面寬度是一樣的,但是藏在介面背後的實作則相差甚遠。
值得一提的是,介面寬度算的不只是參數個數。上方 timerId 的例子就沒有增加參數,卻一樣增加呼叫端的認知負擔。回傳值也是組成介面寬度的一部分。
在《A Philosophy of Software Design》裡,如果以同樣的介面寬度來看,後面承接許多邏輯的,Ousterhout 把這種模組叫做深模組 (deep module)。反之,後面沒承接多少的,則稱做淺模組 (shallow module)。
模組是否越深越好?也許這個問題並沒有標準答案。在那 400 行的邏輯裡,可能有一大半在處理我這輩子不會遇到的情況,而它們仍然要被下載、被讀懂、被維護。
深比較像是一個座標,而不是一個分數。
它說的是複雜度被放到那一邊,並不是說那樣放比較對。
想想昨天的 usePrevious,它只有一個參數、一個回傳值,程式碼量不多。它是淺模組。而淺在這裡不是缺點,它能夠封裝的複雜度,本來就很有限。
複雜度、資訊隱藏、深模組,是我之後臨摹程式碼時主要思考的三把量尺。
它們通常息息相關。我們可能先看見複雜度,才會想問它被藏到哪裡去了;知道什麼被藏起來,才有辦法問一個模組到底藏了多少;當然,也有可能反過來,從「這個東西藏了什麼」作為起手式。
只是這把尺量的對象,並不是一般的模組。它量的是 Hook,而 Hook 活在 React 裡面。什麼時候被呼叫、能不能提早回傳、狀態改變之後由誰決定要不要重新渲染,這些都不是寫 Hook 的人能決定的。
React 這個場地有它自己的規則。那些規則是什麼,又為什麼會在那裡?我們下一篇聊。